Complete ARIA & Keyboard Navigation
A comprehensive guide to ARIA roles, states, properties, and keyboard navigation patterns for building accessible web applications.
Table of Contentsโ
- ARIA Roles
- ARIA States & Properties
- Accessible Notes & Text Areas
- Keyboard Navigation Patterns
- Live Regions & Dynamic Content
- Form Accessibility
- Modal & Dialog Patterns
- Testing & Validation
ARIA Rolesโ
ARIA roles define what an element is semantically to assistive technologies. They communicate the purpose and behavior of elements.
Landmark Rolesโ
Landmark roles help users navigate page structure and find content quickly.
<!-- Site-wide header -->
<header role="banner">
<h1>My Website</h1>
<nav role="navigation" aria-label="Main menu">
<ul>
<li><a href="/home">Home</a></li>
<li><a href="/about">About</a></li>
</ul>
</nav>
</header>
<!-- Main content area -->
<main role="main">
<article>
<h2>Article Title</h2>
<p>Article content...</p>
</article>
</main>
<!-- Sidebar content -->
<aside role="complementary" aria-labelledby="sidebar-heading">
<h3 id="sidebar-heading">Related Links</h3>
<ul>
<li><a href="#">Link 1</a></li>
<li><a href="#">Link 2</a></li>
</ul>
</aside>
<!-- Site footer -->
<footer role="contentinfo">
<p>© 2025 My Website</p>
</footer>
๐ก Best Practice: Use semantic HTML elements (
<header>,<nav>,<main>,<aside>,<footer>) which have implicit ARIA roles. Only add explicit roles when semantic HTML isn't sufficient.
Widget Rolesโ
Widget roles define interactive components and their expected behaviors.
<!-- Custom button -->
<div
role="button"
tabindex="0"
aria-pressed="false"
onkeydown="handleButtonKeydown(event)"
onclick="toggleButton()"
>
Toggle Setting
</div>
<!-- Custom checkbox -->
<div
role="checkbox"
tabindex="0"
aria-checked="false"
aria-labelledby="custom-checkbox-label"
>
<span id="custom-checkbox-label">Enable notifications</span>
</div>
<!-- Dialog/Modal -->
<div
role="dialog"
aria-labelledby="dialog-title"
aria-describedby="dialog-description"
aria-modal="true"
>
<h2 id="dialog-title">Confirm Action</h2>
<p id="dialog-description">Are you sure you want to delete this item?</p>
<button>Cancel</button>
<button>Delete</button>
</div>
<!-- Tab interface -->
<div role="tablist" aria-label="Settings tabs">
<button
role="tab"
aria-selected="true"
aria-controls="general-panel"
id="general-tab"
>
General
</button>
<button
role="tab"
aria-selected="false"
aria-controls="privacy-panel"
id="privacy-tab"
>
Privacy
</button>
</div>
<div role="tabpanel" id="general-panel" aria-labelledby="general-tab">
<h3>General Settings</h3>
<!-- Panel content -->
</div>
Document Structure Rolesโ
<!-- Article with proper heading structure -->
<article role="article">
<header>
<h1>Article Title</h1>
<p>Published on <time datetime="2025-01-15">January 15, 2025</time></p>
</header>
<div role="region" aria-labelledby="section1">
<h2 id="section1">Introduction</h2>
<p>Content...</p>
</div>
</article>
<!-- Data table -->
<table role="table" aria-label="Sales data">
<thead>
<tr role="row">
<th role="columnheader">Month</th>
<th role="columnheader">Sales</th>
<th role="columnheader">Growth</th>
</tr>
</thead>
<tbody>
<tr role="row">
<td role="cell">January</td>
<td role="cell">$10,000</td>
<td role="cell">+5%</td>
</tr>
</tbody>
</table>
<!-- List with custom styling -->
<ul role="list" aria-label="Feature list">
<li role="listitem">Feature 1</li>
<li role="listitem">Feature 2</li>
<li role="listitem">Feature 3</li>
</ul>
ARIA States & Propertiesโ
ARIA states and properties describe the current condition and relationships of elements.
Common Statesโ
States describe the current condition of an element and can change frequently.
<!-- Disabled state -->
<button aria-disabled="true" onclick="handleClick(event)">
Save (processing...)
</button>
<!-- Expanded/Collapsed states -->
<button aria-expanded="false" aria-controls="menu-items" onclick="toggleMenu()">
Menu <span aria-hidden="true">โผ</span>
</button>
<ul id="menu-items" hidden>
<li><a href="#">Item 1</a></li>
<li><a href="#">Item 2</a></li>
</ul>
<!-- Checked states -->
<div
role="checkbox"
aria-checked="false"
tabindex="0"
onclick="toggleCheck(this)"
>
<span class="checkbox-icon" aria-hidden="true">โ</span>
Enable feature
</div>
<div role="checkbox" aria-checked="mixed" tabindex="0">
<span class="checkbox-icon" aria-hidden="true">โ</span>
Select all items (some selected)
</div>
<!-- Selected state -->
<ul role="listbox" aria-label="Color options">
<li role="option" aria-selected="false" tabindex="0">Red</li>
<li role="option" aria-selected="true" tabindex="-1">Blue</li>
<li role="option" aria-selected="false" tabindex="-1">Green</li>
</ul>
<!-- Hidden state -->
<div aria-hidden="true" class="decorative-icon">๐</div>
<span class="sr-only">Celebration complete!</span>
Properties (Relationships & Descriptions)โ
Properties describe relationships between elements and provide additional context.
<!-- Labeling -->
<input
type="email"
id="email-input"
aria-label="Email address"
aria-required="true"
aria-invalid="false"
/>
<!-- Label by reference -->
<h2 id="billing-heading">Billing Information</h2>
<fieldset aria-labelledby="billing-heading">
<input type="text" placeholder="Card number" />
<input type="text" placeholder="Expiry date" />
</fieldset>
<!-- Described by reference -->
<input
type="password"
id="password"
aria-describedby="password-help password-strength"
/>
<div id="password-help">Password must be at least 8 characters long</div>
<div id="password-strength" aria-live="polite">Password strength: Weak</div>
<!-- Controls relationship -->
<button
aria-controls="video-player"
aria-pressed="false"
onclick="togglePlayback()"
>
<span aria-hidden="true">โถ๏ธ</span>
Play video
</button>
<video id="video-player" src="video.mp4"></video>
<!-- Owns relationship -->
<div role="combobox" aria-owns="suggestions-list" aria-expanded="false">
<input type="text" aria-autocomplete="list" />
<ul id="suggestions-list" role="listbox" hidden>
<li role="option">Suggestion 1</li>
<li role="option">Suggestion 2</li>
</ul>
</div>
<!-- Flow to (reading order) -->
<div id="step1">
<h3>Step 1: Enter details</h3>
<input type="text" aria-flowto="step2" />
</div>
<div id="step2" aria-flowto="step3">
<h3>Step 2: Review</h3>
<!-- content -->
</div>
Accessible Notes & Text Areasโ
Creating accessible text input areas for notes, comments, and long-form content.
Basic Textarea Implementationโ
<!-- Semantic HTML approach (preferred) -->
<div class="form-group">
<label for="notes">Meeting Notes</label>
<textarea
id="notes"
name="notes"
rows="6"
cols="50"
aria-required="true"
aria-describedby="notes-help notes-count"
placeholder="Enter your notes here..."
></textarea>
<div id="notes-help" class="help-text">
Include key discussion points and action items
</div>
<div id="notes-count" class="character-count" aria-live="polite">
0 / 500 characters
</div>
</div>
Rich Text Editor (Contenteditable)โ
When you need more than basic textarea functionality:
<div class="rich-editor">
<label id="editor-label">Article Content</label>
<!-- Toolbar -->
<div
role="toolbar"
aria-label="Formatting options"
aria-controls="editor-content"
>
<button
type="button"
aria-pressed="false"
onclick="toggleFormat('bold')"
title="Bold (Ctrl+B)"
>
<strong aria-hidden="true">B</strong>
<span class="sr-only">Bold</span>
</button>
<button
type="button"
aria-pressed="false"
onclick="toggleFormat('italic')"
title="Italic (Ctrl+I)"
>
<em aria-hidden="true">I</em>
<span class="sr-only">Italic</span>
</button>
</div>
<!-- Editor content -->
<div
id="editor-content"
role="textbox"
aria-multiline="true"
aria-labelledby="editor-label"
aria-describedby="editor-help"
contenteditable="true"
spellcheck="true"
tabindex="0"
onkeydown="handleEditorKeydown(event)"
oninput="updateCharCount()"
></div>
<div id="editor-help" class="help-text">
Use Ctrl+B for bold, Ctrl+I for italic. Press Shift+F10 for formatting
options.
</div>
</div>
Error States and Validationโ
<div class="form-group">
<label for="required-notes">Project Description *</label>
<textarea
id="required-notes"
aria-required="true"
aria-invalid="true"
aria-describedby="notes-error notes-help"
>
</textarea>
<!-- Error message -->
<div id="notes-error" role="alert" class="error-message" aria-atomic="true">
<span class="error-icon" aria-hidden="true">โ ๏ธ</span>
Project description is required and must be at least 10 characters long.
</div>
<!-- Help text -->
<div id="notes-help" class="help-text">
Describe the project goals, timeline, and key deliverables.
</div>
</div>
Advanced Notes Featuresโ
<div class="advanced-notes-editor">
<!-- Header with metadata -->
<div class="notes-header">
<h3 id="notes-title">Session Notes</h3>
<div class="notes-meta" aria-label="Note metadata">
<span>Last saved: <time id="last-saved">2 minutes ago</time></span>
<span>Word count: <span id="word-count">247</span></span>
</div>
</div>
<!-- Main editor -->
<div class="editor-container">
<textarea
id="advanced-notes"
aria-labelledby="notes-title"
aria-describedby="editor-status keyboard-shortcuts"
spellcheck="true"
autocorrect="on"
autocapitalize="sentences"
onkeydown="handleAdvancedKeydown(event)"
oninput="autoSave()"
onfocus="showKeyboardHelp()"
onblur="hideKeyboardHelp()"
>
</textarea>
<!-- Auto-save status -->
<div
id="editor-status"
aria-live="polite"
aria-atomic="false"
class="save-status"
>
All changes saved
</div>
</div>
<!-- Keyboard shortcuts help -->
<div
id="keyboard-shortcuts"
class="keyboard-help"
role="region"
aria-label="Keyboard shortcuts"
>
<h4>Keyboard Shortcuts</h4>
<dl>
<dt>Ctrl + S</dt>
<dd>Save notes</dd>
<dt>Ctrl + Z</dt>
<dd>Undo</dd>
<dt>Ctrl + Y</dt>
<dd>Redo</dd>
<dt>Ctrl + F</dt>
<dd>Find in notes</dd>
</dl>
</div>
</div>
Keyboard Navigation Patternsโ
Comprehensive keyboard navigation patterns for different UI components.
Focus Management Principlesโ
// Focus management utilities
class FocusManager {
constructor() {
this.focusableSelectors = [
'a[href]',
'button',
'input',
'select',
'textarea',
'[tabindex]',
'[contenteditable="true"]',
].join(', ');
}
getFocusableElements(container) {
const elements = container.querySelectorAll(this.focusableSelectors);
return Array.from(elements).filter(el => {
return (
!el.disabled && !el.hasAttribute('aria-hidden') && el.tabIndex !== -1
);
});
}
trapFocus(container) {
const focusableElements = this.getFocusableElements(container);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
container.addEventListener('keydown', e => {
if (e.key === 'Tab') {
if (e.shiftKey) {
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
}
} else {
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
}
});
}
}
Button Navigationโ
<div class="button-group" role="group" aria-label="Document actions">
<button
type="button"
onclick="saveDocument()"
onkeydown="handleButtonKeydown(event, 'save')"
>
Save
</button>
<button
type="button"
onclick="previewDocument()"
onkeydown="handleButtonKeydown(event, 'preview')"
>
Preview
</button>
<button
type="button"
onclick="publishDocument()"
onkeydown="handleButtonKeydown(event, 'publish')"
>
Publish
</button>
</div>
<script>
function handleButtonKeydown(event, action) {
// Enter and Space activate buttons
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.target.click();
}
// Arrow key navigation within button group
if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
const buttons = [...event.target.parentNode.querySelectorAll('button')];
const currentIndex = buttons.indexOf(event.target);
let nextIndex;
if (event.key === 'ArrowRight') {
nextIndex = (currentIndex + 1) % buttons.length;
} else {
nextIndex = currentIndex === 0 ? buttons.length - 1 : currentIndex - 1;
}
buttons[nextIndex].focus();
event.preventDefault();
}
}
</script>
Menu Navigationโ
<div class="menu-container">
<button
id="menu-trigger"
aria-haspopup="true"
aria-expanded="false"
aria-controls="main-menu"
onkeydown="handleMenuTriggerKeydown(event)"
onclick="toggleMenu()"
>
File <span aria-hidden="true">โผ</span>
</button>
<ul
id="main-menu"
role="menu"
aria-labelledby="menu-trigger"
hidden
onkeydown="handleMenuKeydown(event)"
>
<li role="menuitem" tabindex="-1">
<button type="button" onclick="newDocument()">
New <kbd aria-hidden="true">Ctrl+N</kbd>
</button>
</li>
<li role="menuitem" tabindex="-1">
<button type="button" onclick="openDocument()">
Open <kbd aria-hidden="true">Ctrl+O</kbd>
</button>
</li>
<li role="separator" aria-hidden="true"></li>
<li
role="menuitem"
aria-haspopup="true"
aria-expanded="false"
tabindex="-1"
>
<button type="button" onclick="toggleRecentMenu()">
Recent Files <span aria-hidden="true">โถ</span>
</button>
<!-- Submenu -->
<ul role="menu" hidden>
<li role="menuitem" tabindex="-1">
<button type="button">Document1.txt</button>
</li>
<li role="menuitem" tabindex="-1">
<button type="button">Document2.txt</button>
</li>
</ul>
</li>
</ul>
</div>
<script>
function handleMenuTriggerKeydown(event) {
switch (event.key) {
case 'Enter':
case ' ':
case 'ArrowDown':
event.preventDefault();
openMenu();
break;
case 'ArrowUp':
event.preventDefault();
openMenu(true); // Focus last item
break;
}
}
function handleMenuKeydown(event) {
const menuItems = [
...event.target
.closest('[role="menu"]')
.querySelectorAll('[role="menuitem"]:not([aria-hidden="true"])'),
];
const currentIndex = menuItems.indexOf(
event.target.closest('[role="menuitem"]')
);
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
const nextIndex = (currentIndex + 1) % menuItems.length;
menuItems[nextIndex].querySelector('button').focus();
break;
case 'ArrowUp':
event.preventDefault();
const prevIndex =
currentIndex === 0 ? menuItems.length - 1 : currentIndex - 1;
menuItems[prevIndex].querySelector('button').focus();
break;
case 'Enter':
case ' ':
event.preventDefault();
event.target.click();
break;
case 'Escape':
closeMenu();
document.getElementById('menu-trigger').focus();
break;
case 'ArrowRight':
// Handle submenu navigation
const submenu = event.target
.closest('[role="menuitem"]')
.querySelector('[role="menu"]');
if (submenu) {
event.preventDefault();
openSubmenu(submenu);
}
break;
case 'ArrowLeft':
// Close submenu or return to parent menu
const parentMenu = event.target
.closest('[role="menu"]')
.parentElement.closest('[role="menu"]');
if (parentMenu) {
event.preventDefault();
closeSubmenu();
// Focus parent menu item
}
break;
}
}
</script>
Tab Navigationโ
<div class="tab-container">
<div
role="tablist"
aria-label="Settings sections"
onkeydown="handleTabListKeydown(event)"
>
<button
role="tab"
id="general-tab"
aria-selected="true"
aria-controls="general-panel"
tabindex="0"
>
General
</button>
<button
role="tab"
id="privacy-tab"
aria-selected="false"
aria-controls="privacy-panel"
tabindex="-1"
>
Privacy
</button>
<button
role="tab"
id="security-tab"
aria-selected="false"
aria-controls="security-panel"
tabindex="-1"
>
Security
</button>
</div>
<div
role="tabpanel"
id="general-panel"
aria-labelledby="general-tab"
tabindex="0"
>
<h3>General Settings</h3>
<label> <input type="checkbox" /> Enable notifications </label>
</div>
<div
role="tabpanel"
id="privacy-panel"
aria-labelledby="privacy-tab"
tabindex="0"
hidden
>
<h3>Privacy Settings</h3>
<label> <input type="checkbox" /> Share usage data </label>
</div>
</div>
<script>
function handleTabListKeydown(event) {
const tabs = [
...event.target
.closest('[role="tablist"]')
.querySelectorAll('[role="tab"]'),
];
const currentIndex = tabs.indexOf(event.target);
let nextIndex;
switch (event.key) {
case 'ArrowRight':
case 'ArrowLeft':
event.preventDefault();
if (event.key === 'ArrowRight') {
nextIndex = (currentIndex + 1) % tabs.length;
} else {
nextIndex = currentIndex === 0 ? tabs.length - 1 : currentIndex - 1;
}
selectTab(tabs[nextIndex]);
break;
case 'Home':
event.preventDefault();
selectTab(tabs[0]);
break;
case 'End':
event.preventDefault();
selectTab(tabs[tabs.length - 1]);
break;
}
}
function selectTab(tab) {
// Update ARIA states
const tablist = tab.closest('[role="tablist"]');
const tabs = [...tablist.querySelectorAll('[role="tab"]')];
tabs.forEach(t => {
t.setAttribute('aria-selected', 'false');
t.tabIndex = -1;
});
tab.setAttribute('aria-selected', 'true');
tab.tabIndex = 0;
tab.focus();
// Show corresponding panel
const panels = [...document.querySelectorAll('[role="tabpanel"]')];
panels.forEach(p => (p.hidden = true));
const targetPanel = document.getElementById(
tab.getAttribute('aria-controls')
);
if (targetPanel) {
targetPanel.hidden = false;
}
}
</script>
Listbox/Combobox Navigationโ
<div class="combobox-container">
<label for="country-input">Choose a country</label>
<div
role="combobox"
aria-expanded="false"
aria-haspopup="listbox"
aria-owns="country-listbox"
>
<input
type="text"
id="country-input"
aria-autocomplete="list"
aria-controls="country-listbox"
onkeydown="handleComboboxKeydown(event)"
oninput="filterOptions(event)"
onfocus="showOptions()"
onblur="hideOptions()"
/>
</div>
<ul
id="country-listbox"
role="listbox"
aria-label="Country options"
hidden
onkeydown="handleListboxKeydown(event)"
>
<li role="option" id="option-us" aria-selected="false">United States</li>
<li role="option" id="option-ca" aria-selected="false">Canada</li>
<li role="option" id="option-uk" aria-selected="false">United Kingdom</li>
</ul>
</div>
<script>
function handleComboboxKeydown(event) {
const listbox = document.getElementById('country-listbox');
const options = [
...listbox.querySelectorAll('[role="option"]:not([hidden])'),
];
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
if (listbox.hidden) {
showOptions();
}
focusOption(options[0]);
break;
case 'ArrowUp':
event.preventDefault();
if (listbox.hidden) {
showOptions();
}
focusOption(options[options.length - 1]);
break;
case 'Escape':
hideOptions();
break;
case 'Enter':
if (!listbox.hidden) {
const selectedOption = listbox.querySelector(
'[aria-selected="true"]'
);
if (selectedOption) {
selectOption(selectedOption);
}
}
break;
}
}
function handleListboxKeydown(event) {
const options = [
...event.target
.closest('[role="listbox"]')
.querySelectorAll('[role="option"]:not([hidden])'),
];
const currentIndex = options.indexOf(event.target);
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
const nextIndex = (currentIndex + 1) % options.length;
focusOption(options[nextIndex]);
break;
case 'ArrowUp':
event.preventDefault();
const prevIndex =
currentIndex === 0 ? options.length - 1 : currentIndex - 1;
focusOption(options[prevIndex]);
break;
case 'Enter':
case ' ':
event.preventDefault();
selectOption(event.target);
break;
case 'Escape':
hideOptions();
document.getElementById('country-input').focus();
break;
case 'Home':
event.preventDefault();
focusOption(options[0]);
break;
case 'End':
event.preventDefault();
focusOption(options[options.length - 1]);
break;
// Type-ahead support
default:
if (event.key.length === 1) {
const char = event.key.toLowerCase();
const matchingOption = options.find(option =>
option.textContent.toLowerCase().startsWith(char)
);
if (matchingOption) {
focusOption(matchingOption);
}
}
break;
}
}
function focusOption(option) {
const options = [
...option.parentElement.querySelectorAll('[role="option"]'),
];
options.forEach(opt => opt.setAttribute('aria-selected', 'false'));
option.setAttribute('aria-selected', 'true');
option.focus();
// Update input value for preview
const input = document.getElementById('country-input');
input.value = option.textContent;
}
</script>
Live Regions & Dynamic Contentโ
Managing dynamic content updates for screen reader users.
Live Region Typesโ
<!-- Polite announcements (don't interrupt) -->
<div id="status-updates" aria-live="polite" aria-atomic="false" class="sr-only">
<!-- Status messages appear here -->
</div>
<!-- Assertive announcements (interrupt current speech) -->
<div
id="error-announcements"
aria-live="assertive"
aria-atomic="true"
role="alert"
class="sr-only"
>
<!-- Critical errors appear here -->
</div>
<!-- Off - no announcements -->
<div id="debug-info" aria-live="off" aria-relevant="text additions removals">
<!-- Debug info that shouldn't be announced -->
</div>
<!-- Log for sequential updates -->
<div
id="activity-log"
role="log"
aria-label="Recent activity"
aria-live="polite"
>
<ul>
<li>User John logged in at 2:30 PM</li>
<li>Document saved at 2:32 PM</li>
<!-- New items added here -->
</ul>
</div>
<!-- Timer/countdown -->
<div id="timer-display" role="timer" aria-live="polite" aria-atomic="true">
<span class="time">05:00</span>
<span class="label">minutes remaining</span>
</div>
Dynamic Content Managementโ
class LiveRegionManager {
constructor() {
this.regions = {
status: this.createRegion('polite', false),
alert: this.createRegion('assertive', true),
log: this.createRegion('polite', false, 'log'),
};
}
createRegion(level, atomic, role = null) {
const region = document.createElement('div');
region.setAttribute('aria-live', level);
region.setAttribute('aria-atomic', atomic.toString());
region.className = 'sr-only';
if (role) {
region.setAttribute('role', role);
}
document.body.appendChild(region);
return region;
}
announce(message, type = 'status', delay = 100) {
// Small delay ensures screen readers catch the update
setTimeout(() => {
const region = this.regions[type];
if (region) {
region.textContent = message;
// Clear after announcement to allow repeated messages
setTimeout(() => {
region.textContent = '';
}, 1000);
}
}, delay);
}
appendToLog(message, timestamp = true) {
const logRegion = this.regions.log;
const entry = document.createElement('div');
if (timestamp) {
const time = new Date().toLocaleTimeString();
entry.textContent = `${time}: ${message}`;
} else {
entry.textContent = message;
}
logRegion.appendChild(entry);
// Limit log entries to prevent performance issues
const entries = logRegion.children;
if (entries.length > 50) {
logRegion.removeChild(entries[0]);
}
}
}
// Usage examples
const liveRegions = new LiveRegionManager();
// Status updates
function saveDocument() {
// ... save logic
liveRegions.announce('Document saved successfully');
}
// Error alerts
function handleError(error) {
liveRegions.announce(`Error: ${error.message}`, 'alert');
}
// Activity logging
function logActivity(activity) {
liveRegions.appendToLog(activity);
}
Form Validation with Live Feedbackโ
<form novalidate onsubmit="handleFormSubmit(event)">
<div class="form-group">
<label for="username">Username *</label>
<input
type="text"
id="username"
name="username"
required
minlength="3"
aria-describedby="username-help username-feedback"
oninput="validateField(this)"
onblur="validateField(this, true)"
/>
<div id="username-help" class="help-text">
Username must be at least 3 characters long
</div>
<div
id="username-feedback"
aria-live="polite"
aria-atomic="true"
class="validation-feedback"
>
<!-- Validation messages appear here -->
</div>
</div>
<div class="form-group">
<label for="email">Email Address *</label>
<input
type="email"
id="email"
name="email"
required
aria-describedby="email-help email-feedback"
oninput="validateField(this)"
onblur="validateField(this, true)"
/>
<div id="email-help" class="help-text">Enter a valid email address</div>
<div
id="email-feedback"
aria-live="polite"
class="validation-feedback"
></div>
</div>
<!-- Form-level feedback -->
<div
id="form-feedback"
role="alert"
aria-live="assertive"
class="form-errors"
>
<!-- Form submission errors appear here -->
</div>
<button type="submit">Submit</button>
</form>
<script>
function validateField(field, showSuccess = false) {
const feedback = document.getElementById(field.id + '-feedback');
const isValid = field.checkValidity();
// Clear previous state
field.removeAttribute('aria-invalid');
feedback.textContent = '';
feedback.className = 'validation-feedback';
if (!isValid) {
field.setAttribute('aria-invalid', 'true');
feedback.className = 'validation-feedback error';
feedback.textContent = field.validationMessage;
} else if (showSuccess && field.value.trim()) {
feedback.className = 'validation-feedback success';
feedback.textContent = 'Valid';
}
}
function handleFormSubmit(event) {
event.preventDefault();
const form = event.target;
const formFeedback = document.getElementById('form-feedback');
const isValid = form.checkValidity();
if (!isValid) {
// Show form-level errors
formFeedback.innerHTML = `
`;
// Focus first invalid field
const firstInvalid = form.querySelector(':invalid');
if (firstInvalid) {
firstInvalid.focus();
}
} else {
formFeedback.innerHTML = '<p>Form submitted successfully!</p>';
// Process form...
}
}
</script>
Form Accessibilityโ
Comprehensive form accessibility patterns and techniques.
Field Grouping and Relationshipsโ
<form>
<!-- Required field indicators -->
<fieldset>
<legend>
Personal Information
<span class="required-note">(* indicates required fields)</span>
</legend>
<div class="form-row">
<div class="form-group">
<label for="first-name">
First Name *
<span class="sr-only">(required)</span>
</label>
<input
type="text"
id="first-name"
name="firstName"
required
autocomplete="given-name"
aria-describedby="name-help"
/>
</div>
<div class="form-group">
<label for="last-name">
Last Name *
<span class="sr-only">(required)</span>
</label>
<input
type="text"
id="last-name"
name="lastName"
required
autocomplete="family-name"
/>
</div>
</div>
<div id="name-help" class="help-text">
Enter your full legal name as it appears on official documents
</div>
</fieldset>
<!-- Radio button groups -->
<fieldset>
<legend>Contact Preference</legend>
<div role="radiogroup" aria-describedby="contact-help">
<label>
<input type="radio" name="contact" value="email" checked />
Email
</label>
<label>
<input type="radio" name="contact" value="phone" />
Phone
</label>
<label>
<input type="radio" name="contact" value="mail" />
Mail
</label>
</div>
<div id="contact-help" class="help-text">
Choose how you'd like us to contact you
</div>
</fieldset>
<!-- Checkbox groups -->
<fieldset>
<legend>Interests (select all that apply)</legend>
<div class="checkbox-group">
<label>
<input type="checkbox" name="interests" value="technology" />
Technology
</label>
<label>
<input type="checkbox" name="interests" value="design" />
Design
</label>
<label>
<input type="checkbox" name="interests" value="business" />
Business
</label>
</div>
</fieldset>
<!-- Complex input with multiple parts -->
<fieldset>
<legend>Phone Number</legend>
<div class="phone-input" role="group" aria-labelledby="phone-legend">
<label for="phone-country" class="sr-only">Country code</label>
<select
id="phone-country"
name="phoneCountry"
aria-describedby="phone-help"
>
<option value="+1">+1 (US)</option>
<option value="+44">+44 (UK)</option>
<option value="+33">+33 (FR)</option>
</select>
<label for="phone-number" class="sr-only">Phone number</label>
<input
type="tel"
id="phone-number"
name="phoneNumber"
placeholder="(555) 123-4567"
autocomplete="tel"
aria-describedby="phone-help"
/>
</div>
<div id="phone-help" class="help-text">
Include area code for US numbers
</div>
</fieldset>
</form>
Advanced Input Typesโ
<div class="advanced-inputs">
<!-- Date picker -->
<div class="form-group">
<label for="birth-date">Date of Birth</label>
<input
type="date"
id="birth-date"
name="birthDate"
min="1900-01-01"
max="2023-12-31"
aria-describedby="date-help"
onchange="validateAge(this)"
/>
<div id="date-help" class="help-text">Must be 18 years or older</div>
</div>
<!-- Range slider -->
<div class="form-group">
<label for="salary-range">Expected Salary Range</label>
<div class="range-container">
<input
type="range"
id="salary-range"
name="salaryRange"
min="30000"
max="200000"
step="5000"
value="75000"
aria-describedby="salary-help salary-value"
oninput="updateRangeValue(this)"
/>
<output id="salary-value" for="salary-range" aria-live="polite">
$75,000
</output>
</div>
<div id="salary-help" class="help-text">
Adjust slider to set your expected salary range
</div>
</div>
<!-- File upload -->
<div class="form-group">
<label for="resume-upload">Upload Resume</label>
<input
type="file"
id="resume-upload"
name="resume"
accept=".pdf,.doc,.docx"
aria-describedby="file-help file-status"
onchange="handleFileUpload(this)"
/>
<div id="file-help" class="help-text">
Accepted formats: PDF, DOC, DOCX (max 5MB)
</div>
<div id="file-status" aria-live="polite" class="file-status">
<!-- Upload status appears here -->
</div>
</div>
<!-- Multi-step progress -->
<div class="progress-container">
<div
role="progressbar"
aria-valuenow="2"
aria-valuemin="1"
aria-valuemax="4"
aria-labelledby="progress-label"
>
<div id="progress-label">Step 2 of 4: Contact Information</div>
<div class="progress-bar">
<div class="progress-fill" style="width: 50%"></div>
</div>
</div>
</div>
</div>
<script>
function updateRangeValue(slider) {
const output = document.getElementById('salary-value');
const value = parseInt(slider.value);
output.textContent = `${value.toLocaleString()}`;
}
function handleFileUpload(input) {
const status = document.getElementById('file-status');
const file = input.files[0];
if (file) {
if (file.size > 5 * 1024 * 1024) {
// 5MB
status.innerHTML =
'<span class="error">File too large. Maximum size is 5MB.</span>';
input.value = '';
} else {
status.innerHTML = ``;
}
} else {
status.textContent = '';
}
}
</script>
Modal & Dialog Patternsโ
Accessible modal and dialog implementations with proper focus management.
Basic Modal Dialogโ
<div
id="modal-overlay"
class="modal-overlay"
hidden
onclick="handleOverlayClick(event)"
>
<div
id="confirmation-modal"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
aria-describedby="modal-description"
class="modal"
onkeydown="handleModalKeydown(event)"
>
<div class="modal-header">
<h2 id="modal-title">Confirm Deletion</h2>
<button
type="button"
class="close-button"
aria-label="Close dialog"
onclick="closeModal()"
>
<span aria-hidden="true">ร</span>
</button>
</div>
<div class="modal-body">
<p id="modal-description">
Are you sure you want to delete this item? This action cannot be undone.
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeModal()">
Cancel
</button>
<button
type="button"
class="btn btn-danger"
onclick="confirmDelete()"
autofocus
>
Delete
</button>
</div>
</div>
</div>
<script>
let previousActiveElement = null;
const focusManager = new FocusManager();
function openModal(modalId) {
// Store current focus
previousActiveElement = document.activeElement;
const overlay = document.getElementById('modal-overlay');
const modal = document.getElementById(modalId);
// Show modal
overlay.hidden = false;
// Trap focus within modal
focusManager.trapFocus(modal);
// Focus first focusable element or autofocus element
const autofocusElement = modal.querySelector('[autofocus]');
const firstFocusable = focusManager.getFocusableElements(modal)[0];
if (autofocusElement) {
autofocusElement.focus();
} else if (firstFocusable) {
firstFocusable.focus();
}
// Prevent body scroll
document.body.style.overflow = 'hidden';
// Announce to screen readers
setTimeout(() => {
liveRegions.announce('Dialog opened', 'status');
}, 100);
}
function closeModal() {
const overlay = document.getElementById('modal-overlay');
// Hide modal
overlay.hidden = true;
// Restore focus
if (previousActiveElement) {
previousActiveElement.focus();
previousActiveElement = null;
}
// Restore body scroll
document.body.style.overflow = '';
// Announce to screen readers
liveRegions.announce('Dialog closed', 'status');
}
function handleModalKeydown(event) {
if (event.key === 'Escape') {
closeModal();
}
}
function handleOverlayClick(event) {
if (event.target === event.currentTarget) {
closeModal();
}
}
function confirmDelete() {
// Perform deletion
liveRegions.announce('Item deleted successfully', 'status');
closeModal();
}
</script>
Alert Dialogโ
<div
id="alert-modal"
role="alertdialog"
aria-modal="true"
aria-labelledby="alert-title"
aria-describedby="alert-description"
class="modal alert-modal"
hidden
>
<div class="modal-content">
<div class="alert-icon" aria-hidden="true">โ ๏ธ</div>
<h2 id="alert-title">System Error</h2>
<p id="alert-description">
An unexpected error occurred. Your work has been saved automatically.
</p>
<button
type="button"
class="btn btn-primary"
onclick="closeAlertModal()"
autofocus
>
OK
</button>
</div>
</div>
Form Dialogโ
<div
id="form-modal"
role="dialog"
aria-modal="true"
aria-labelledby="form-modal-title"
class="modal form-modal"
hidden
>
<form onsubmit="handleFormModalSubmit(event)" novalidate>
<div class="modal-header">
<h2 id="form-modal-title">Add New Contact</h2>
<button type="button" aria-label="Close" onclick="closeFormModal()">
ร
</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="contact-name">Name *</label>
<input
type="text"
id="contact-name"
name="name"
required
aria-describedby="name-error"
autofocus
/>
<div
id="name-error"
role="alert"
aria-live="assertive"
class="error-message"
></div>
</div>
<div class="form-group">
<label for="contact-email">Email</label>
<input
type="email"
id="contact-email"
name="email"
aria-describedby="email-error"
/>
<div
id="email-error"
role="alert"
aria-live="assertive"
class="error-message"
></div>
</div>
</div>
<div class="modal-footer">
<button type="button" onclick="closeFormModal()">Cancel</button>
<button type="submit">Add Contact</button>
</div>
</form>
</div>
Testing & Validationโ
Tools and techniques for testing accessibility implementation.
Automated Testingโ
// Basic accessibility testing utilities
class AccessibilityTester {
constructor() {
this.violations = [];
}
testFocusManagement() {
const focusableElements = document.querySelectorAll(
'a, button, input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
focusableElements.forEach(element => {
if (element.tabIndex === 0 && !this.isVisible(element)) {
this.violations.push({
type: 'focus',
element: element,
message: 'Focusable element is not visible',
});
}
});
}
testLabels() {
const inputs = document.querySelectorAll('input, select, textarea');
inputs.forEach(input => {
const hasLabel = this.hasLabel(input);
if (!hasLabel) {
this.violations.push({
type: 'label',
element: input,
message: 'Form control missing label',
});
}
});
}
testHeadingStructure() {
const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
let previousLevel = 0;
headings.forEach((heading, index) => {
const level = parseInt(heading.tagName.charAt(1));
if (index === 0 && level !== 1) {
this.violations.push({
type: 'heading',
element: heading,
message: 'Page should start with h1',
});
}
if (level > previousLevel + 1) {
this.violations.push({
type: 'heading',
element: heading,
message: `Heading level jumps from h${previousLevel} to h${level}`,
});
}
previousLevel = level;
});
}
testAriaLabels() {
const elementsWithAriaLabel = document.querySelectorAll('[aria-label]');
const elementsWithAriaLabelledby =
document.querySelectorAll('[aria-labelledby]');
elementsWithAriaLabelledby.forEach(element => {
const ids = element.getAttribute('aria-labelledby').split(' ');
ids.forEach(id => {
if (!document.getElementById(id)) {
this.violations.push({
type: 'aria',
element: element,
message: `aria-labelledby references non-existent id: ${id}`,
});
}
});
});
}
hasLabel(input) {
// Check for label element
if (input.labels && input.labels.length > 0) return true;
// Check for aria-label
if (input.getAttribute('aria-label')) return true;
// Check for aria-labelledby
if (input.getAttribute('aria-labelledby')) {
const ids = input.getAttribute('aria-labelledby').split(' ');
return ids.every(id => document.getElementById(id));
}
return false;
}
isVisible(element) {
const style = window.getComputedStyle(element);
return (
style.display !== 'none' &&
style.visibility !== 'hidden' &&
style.opacity !== '0'
);
}
runAllTests() {
this.violations = [];
this.testFocusManagement();
this.testLabels();
this.testHeadingStructure();
this.testAriaLabels();
return this.violations;
}
generateReport() {
const violations = this.runAllTests();
console.group('Accessibility Test Results');
if (violations.length === 0) {
console.log('โ
No violations found');
} else {
console.log(`โ Found ${violations.length} violations:`);
violations.forEach((violation, index) => {
console.group(`${index + 1}. ${violation.type.toUpperCase()}`);
console.log('Message:', violation.message);
console.log('Element:', violation.element);
console.groupEnd();
});
}
console.groupEnd();
return violations;
}
}
// Usage
const tester = new AccessibilityTester();
tester.generateReport();
Manual Testing Checklistโ
## Accessibility Testing Checklist
### Keyboard Navigation
- [ ] All interactive elements are reachable via Tab key
- [ ] Tab order is logical and matches visual layout
- [ ] Focus indicators are visible and clear
- [ ] Escape key closes modals/menus appropriately
- [ ] Arrow keys work in menus, tabs, and other widgets
- [ ] Enter/Space activate buttons and links
### Screen Reader Testing
- [ ] Page has proper heading structure (h1, h2, h3, etc.)
- [ ] All images have appropriate alt text
- [ ] Form controls have labels
- [ ] Error messages are announced
- [ ] Dynamic content updates are announced
- [ ] Landmarks help with navigation
### Visual Testing
- [ ] Text has sufficient color contrast (4.5:1 for normal, 3:1 for large)
- [ ] Focus indicators are visible
- [ ] Text is readable when zoomed to 200%
- [ ] No information conveyed by color alone
- [ ] Content reflows properly on mobile devices
### ARIA Testing
- [ ] ARIA roles are used appropriately
- [ ] ARIA states update correctly (expanded, selected, etc.)
- [ ] aria-live regions announce changes
- [ ] No invalid ARIA attribute combinations
### Form Testing
- [ ] Required fields are clearly marked
- [ ] Validation errors are associated with fields
- [ ] Error messages are descriptive
- [ ] Success messages are announced
- [ ] Field groups use fieldset/legend appropriately
Testing Tools Integrationโ
// Integration with popular testing tools
class AccessibilityTestSuite {
async runAxeTests() {
// Requires axe-core library
if (typeof axe !== 'undefined') {
try {
const results = await axe.run();
console.log('Axe test results:', results);
return results.violations;
} catch (error) {
console.error('Axe testing failed:', error);
return [];
}
} else {
console.warn('axe-core library not loaded');
return [];
}
}
simulateScreenReader() {
// Basic screen reader simulation
const elements = document.querySelectorAll('*');
const announcement = [];
elements.forEach(element => {
if (this.isVisible(element) && this.hasTextContent(element)) {
const role =
element.getAttribute('role') || this.getImplicitRole(element);
const label = this.getAccessibleName(element);
if (label) {
announcement.push({
element: element,
role: role,
name: label,
states: this.getStates(element),
});
}
}
});
return announcement;
}
getImplicitRole(element) {
const roleMap = {
button: 'button',
a: element.href ? 'link' : null,
input: this.getInputRole(element),
h1: 'heading',
h2: 'heading',
h3: 'heading',
h4: 'heading',
h5: 'heading',
h6: 'heading',
nav: 'navigation',
main: 'main',
header: 'banner',
footer: 'contentinfo',
aside: 'complementary',
};
return roleMap[element.tagName.toLowerCase()] || null;
}
getInputRole(input) {
const typeRoleMap = {
checkbox: 'checkbox',
radio: 'radio',
range: 'slider',
text: 'textbox',
email: 'textbox',
password: 'textbox',
search: 'searchbox',
};
return typeRoleMap[input.type] || 'textbox';
}
getAccessibleName(element) {
// Priority order for accessible name calculation
// 1. aria-label
if (element.hasAttribute('aria-label')) {
return element.getAttribute('aria-label');
}
// 2. aria-labelledby
if (element.hasAttribute('aria-labelledby')) {
const ids = element.getAttribute('aria-labelledby').split(' ');
const names = ids
.map(id => {
const referencedElement = document.getElementById(id);
return referencedElement ? referencedElement.textContent.trim() : '';
})
.filter(name => name);
if (names.length > 0) {
return names.join(' ');
}
}
// 3. Associated label
if (element.labels && element.labels.length > 0) {
return element.labels[0].textContent.trim();
}
// 4. alt attribute (for images)
if (element.hasAttribute('alt')) {
return element.getAttribute('alt');
}
// 5. title attribute
if (element.hasAttribute('title')) {
return element.getAttribute('title');
}
// 6. Text content (for certain elements)
if (
['button', 'a', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(
element.tagName.toLowerCase()
)
) {
return element.textContent.trim();
}
return null;
}
getStates(element) {
const states = [];
// Common ARIA states
const ariaStates = [
'aria-expanded',
'aria-checked',
'aria-selected',
'aria-pressed',
'aria-disabled',
'aria-invalid',
'aria-hidden',
];
ariaStates.forEach(state => {
if (element.hasAttribute(state)) {
const value = element.getAttribute(state);
states.push(`${state}: ${value}`);
}
});
// HTML states
if (element.disabled) states.push('disabled');
if (element.required) states.push('required');
if (element.checked) states.push('checked');
return states;
}
isVisible(element) {
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
return (
rect.width > 0 &&
rect.height > 0 &&
style.opacity !== '0' &&
style.visibility !== 'hidden' &&
style.display !== 'none'
);
}
hasTextContent(element) {
return element.textContent && element.textContent.trim().length > 0;
}
}
// Usage
const testSuite = new AccessibilityTestSuite();
// Run manual tests
const manualResults = new AccessibilityTester().runAllTests();
// Run axe tests (if available)
testSuite.runAxeTests().then(axeResults => {
console.log('Combined test results:', {
manual: manualResults,
axe: axeResults,
});
});
// Simulate screen reader output
const srOutput = testSuite.simulateScreenReader();
console.log('Screen reader simulation:', srOutput);
Summaryโ
This comprehensive guide covers:
- ARIA Roles: Semantic meaning for assistive technologies
- ARIA States & Properties: Dynamic states and relationships
- Keyboard Navigation: Proper focus management and interaction patterns
- Live Regions: Dynamic content announcements
- Form Accessibility: Proper labeling, validation, and error handling
- Modal Patterns: Focus trapping and proper dialog implementation
- Testing: Automated and manual accessibility validation
Key Principles to Rememberโ
- Semantic HTML First: Use native elements when possible before adding ARIA
- Progressive Enhancement: Build accessible foundations, then enhance
- Test with Real Users: Nothing replaces testing with actual assistive technology users
- Focus Management: Always know where focus is and where it should go
- Clear Communication: Provide clear, descriptive labels and instructions
Quick Referenceโ
Essential ARIA attributes:
aria-label,aria-labelledby,aria-describedbyfor labelingaria-expanded,aria-selected,aria-checkedfor statesaria-live,role="alert"for dynamic contentaria-hiddento hide decorative elements
Key keyboard patterns:
- Tab/Shift+Tab for focus navigation
- Enter/Space for activation
- Arrow keys for widget navigation
- Escape for dismissal/cancellation
Testing priorities:
- Keyboard-only navigation
- Screen reader compatibility
- Color contrast and visual clarity
- Error handling and feedback
- Mobile accessibility